MessageModule에 Inversion of Control 적용하기
Q.
NestJS에서 서비스 MessageService에 의존하는 Client가 과도하게 많은 A의 의존성을 함께 의존하게 되는 문제가 발생하여 의존성 역전과 Inversion of Control을 통해 결합도를 줄이고자 한다.
AS-IS
MessageService는 여러개의 UseCase들에게 직접 의존성을 가지고 있다. 각 UseCase들은 Client가 Service에게 전달해준 correlationId, context, payload 등을 ISendMessageCommand 타입 커맨드 객체로 변환하는 역할을 가지고 있으며, 이 과정에서 Repository, 타 Service, 타 UseCase에 의존하게 된다. 결과적으로 Client는 본인은 관심없는 MessageModule의 여러 Usecase들이 의존하는 모든 컴포넌트에 함께 의존하게 된다.
TO-BE
MessageService는 단지 ISendMessage 인터페이스에 의존한다. 따라서 Client는 전송하고 싶은 메시지가 있다면 ISendMessage를 구현한 UseCase 객체를 찾아 MessageService에게 대리자 형태로 전달하게 된다.
QUESTION
- 의존성 역전원칙을 사용하였을 경우, AS-IS에서 고민하던 과도한 결합도 문제는 해결되는가?
- UseCase가 커맨드 객체로 변환하는 책임을 갖게 하기 VERSUS MessageService 안에서 적당한 adapter 객체(SMS, FCM 등)하나를 UseCase 객체에게 전달하여 UseCase가 어댑터에 의존할 책임을 피할 수 있게 하기
Chat GPT 5 Answer
결론:
-
DIP를 올바르게 적용하면 Client의 과도한 결합은 해소된다. 단, UseCase 인스턴스를 Client가 직접 선택·생성하면 결합이 이동할 뿐이다. 해결책은 MessageService 내부의 핸들러 레지스트리로 라우팅하는 것.
-
책임 배분은 “UseCase가 커맨드 변환”보다 “UseCase는 도메인 의사결정만, 채널 어댑터는 Service가 선택”이 장기 유지보수에 유리하다. 즉, UseCase → Canonical DTO, MessageService → 채널 전략/어댑터 선택.
권장 구조(포트/어댑터 + 전략)
-
입력 포트:
ISendMessageHandler<C extends ISendMessageCommand>
-
출력 포트: IMessageGateway(SMS, FCM 등 구현)
-
오케스트레이터: MessageService
역할: 커맨드 타입에 맞는 핸들러를 레지스트리에서 찾음 → 핸들러가 표준 DTO 생성 → 정책/전략으로 게이트웨이 선택 후 전송.
핵심 타입
// Command: Client는 이것만 안다
export interface ISendMessageCommand {
type: 'INVITE' | 'REFUND' | 'OTP' | string;
correlationId: string;
context: Record<string, unknown>;
payload: unknown;
}
// 핸들러 입력 포트
export interface ISendMessageHandler<C extends ISendMessageCommand = ISendMessageCommand> {
readonly type: C['type'];
handle(cmd: C): Promise<CanonicalMessage>;
}
// 표준 DTO: 채널 독립
export type CanonicalMessage = {
recipients: string[]; // phone, token 등
templateId?: string;
text?: string;
data?: Record<string, unknown>; // 템플릿 변수
meta?: { correlationId: string; tags?: string[] };
};
// 출력 포트: 채널 게이트웨이
export interface IMessageGateway {
readonly channel: 'SMS' | 'FCM' | 'EMAIL';
send(msg: CanonicalMessage): Promise<{ messageId: string }>;
}
MessageService: 레지스트리 + 전략
@Injectable()
export class MessageService {
constructor(
@Inject('HANDLER_REGISTRY') private readonly handlers: Map<string, ISendMessageHandler>,
@Inject('GATEWAYS') private readonly gateways: IMessageGateway[], // 다중 주입
@Inject('CHANNEL_POLICY') private readonly pick: (msg: CanonicalMessage) => 'SMS'|'FCM'|'EMAIL',
) {}
async send(cmd: ISendMessageCommand) {
const handler = this.handlers.get(cmd.type);
if (!handler) throw new Error(`No handler for type=${cmd.type}`);
const canonical = await handler.handle(cmd);
const channel = this.pick(canonical);
const gw = this.gateways.find(g => g.channel === channel);
if (!gw) throw new Error(`Gateway not found for channel=${channel}`);
return gw.send(canonical);
}
}
핸들러 등록 패턴(토큰+팩토리)
export const HANDLER_REGISTRY = {
provide: 'HANDLER_REGISTRY',
useFactory: (handlers: ISendMessageHandler[]) => {
const map = new Map<string, ISendMessageHandler>();
handlers.forEach(h => map.set(h.type, h));
return map;
},
inject: [ 'INVITE_HANDLER', 'REFUND_HANDLER', /* ... */ ],
};
@Module({
providers: [
// 개별 핸들러
{ provide: 'INVITE_HANDLER', useClass: InviteSendHandler },
{ provide: 'REFUND_HANDLER', useClass: RefundSendHandler },
HANDLER_REGISTRY,
// 게이트웨이들
{ provide: 'GATEWAYS', useClass: SmsGateway, multi: true },
{ provide: 'GATEWAYS', useClass: FcmGateway, multi: true },
// 채널 선택 정책(전략)
{ provide: 'CHANNEL_POLICY', useValue: (m: CanonicalMessage) => m.templateId ? 'SMS' : 'FCM' },
MessageService,
],
exports: [MessageService], // Client는 이것만 의존
})
export class MessageModule {}
핸들러 예시(UseCase는 도메인에 집중)
@Injectable()
export class InviteSendHandler implements ISendMessageHandler<InviteCommand> {
readonly type = 'INVITE' as const;
constructor(
private readonly repo: LessonRepo, // 도메인 의존
private readonly tmpl: TemplateService, // 도메인 규칙/표준 DTO 준비
) {}
async handle(cmd: InviteCommand): Promise<CanonicalMessage> {
const lesson = await this.repo.get(cmd.payload.lessonId);
const text = this.tmpl.render('invite', { title: lesson.title, host: lesson.host });
return {
recipients: cmd.payload.phones,
text,
meta: { correlationId: cmd.correlationId, tags: ['invite'] },
};
}
}
1) DIP로 결합도 해소 여부
-
해결됨: Client는 MessageService와 ISendMessageCommand만 의존. UseCase, Repo, 타 서비스에 대한 의존은 MessageModule 내부로 캡슐화.
-
주의점: Client가 ISendMessage 구현체를 스스로 찾아 주입하는 설계는 안 됨. 그 순간 Client가 구현 디테일을 안다. 핸들러 선택은 Service 내부 레지스트리로.
2) 책임 배분 비교
A. “UseCase가 커맨드 → 채널별 어댑터까지 직접 의존”
-
장점: 핸들러가 end-to-end를 소유. 단순.
-
단점: 채널 추가·변경 시 모든 핸들러 수정. 채널 전략 테스트가 분산. 결합도↑.
B. “UseCase는 Canonical DTO만 반환, 어댑터 선택은 Service”
-
장점: 채널 변경이 중앙 정책 한 곳. 핸들러는 도메인 규칙에만 집중. 테스트 분리가능. 확장 용이.
-
단점: Service에 오케스트레이션 로직이 모인다. 그러나 전략/정책 함수로 분리하면 완화.
권장: B. UseCase는 표준 DTO 생성 책임. 채널 선택과 어댑터 의존성은 MessageService의 전략으로 역전.
실무 팁
-
CQRS/Nest CQRS: CommandBus +
ICommandHandler<SendMessage>
로 레지스트리 자동화 가능. 도입 비용 대비 이득이 크면 채택. -
상관아이디 전파: nestjs-cls 또는 AsyncLocalStorage로 로깅/게이트웨이까지 자동 전파.
-
스키마 검증: 커맨드는 Zod/class-validator로 입력 검증. 핸들러 내부는 도메인 불변식 검증을 분리.
-
정책 테스트: 채널 정책 함수를 순수 함수로 유지하고 단위 테스트 다수.
-
버전링: type + version 필드로 핸들러 마이그레이션 안전화.
-
옵저버빌리티: Service에서 before/after send 훅으로 메트릭·리트라이·서킷브레이커 적용.
요약:
-
Client는 MessageService.send(command)만 호출.
-
Service는 핸들러 레지스트리로 라우팅.
-
UseCase는 도메인 → Canonical DTO 변환.
-
채널·어댑터는 Service 전략으로 선택.
이 구성이 DIP와 IoC를 충족하고 결합도를 실질적으로 낮춘다.
Client 예시 스니펫
// client.ts
import { MessageService } from '@/message/message.service';
import { ISendMessageCommand } from '@/message/contracts';
// 예시 커맨드 객체
const command: ISendMessageCommand = {
type: 'INVITE',
correlationId: 'abc-123',
context: { lessonId: 'L001', inviter: 'hostUserId' },
payload: { phones: ['01012345678'], name: '홍길동' },
};
// NestJS DI로 주입받은 MessageService 사용
export class SomeClient {
constructor(private readonly messageService: MessageService) {}
async invite() {
const result = await this.messageService.send(command);
console.log('message sent:', result);
}
}